<?php

/**
 * This file is part of Switcher.
 * 
 * Switcher is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Switcher is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with Switcher. If not, see <http://www.gnu.org/licenses/>.
 * 
 * @license http://www.opensource.org/licenses/gpl-3.0.html GPL-3.0
 */

namespace switcher\renderers;

/**
 * 
 * 
 *
 * @author Dri <switcher.framework@gmail.com>
 * @license http://www.opensource.org/licenses/gpl-3.0.html GPL-3.0
 * @package switcher\renderers
 * @since 1.0.0
 */
final class XmlRenderer extends \switcher\renderers\Renderer
{
	/**
	 * The default content type
	 * @var string
	 */
	const DEFAULT_CONTENT_TYPE = 'text/html';
	
	/**
	 * The template XML namespace
	 * @var string
	 */
	const TEMPLATE_NAMESPACE = 'http://www.switcherframework.org/template';
	
	/**
	 * Forbidden associations of template directives
	 * @var string
	 */
	private static $_TEMPLATE_FORBIDDEN = '
		(bundle ^ bundle-var) |
		(for & for-each) |
		(include & composition-of) |
		(for-var & !for) |
		(for-each-var & !for-each) |
		(for-children-var & !for-children)
	';
	
	/**
	 * Expression delimiter in expression output
	 * @var string
	 */
	private static $_EXPRESSION_DELIMITER = '{EXPRESSION_DELIMITER}';
	
	/**
	 * The XML template
	 * @var \DOMDocument
	 */
	private $inputDocument;
	
	/**
	 * The composition XML template if any
	 * @var \DOMDocument
	 */
	private $composition;
	
	/**
	 * The AJAX configuration is optional and allows to render only some parts of the template
	 * with AJAX requests. It contains the xPath to some XML nodes and optionally XML namespaces
	 * for templates with at least one namespace. Namespaces are declared as an associative array
	 * of prefix/namespace pairs. This should not be populated manually but through XML declaration.
	 * 
	 * <p>Example :
	 * <code>
	 * array(
	 *     'xpath' => "//h:div[@id='content']/*",
	 *     'namespaces' => array(
	 *         'h' => 'http://www.w3.org/1999/xhtml'
	 *     )
	 * );
	 * </code>
	 * </p>
	 * @var array
	 */
	protected $ajax;
	
	/**
	 * Indicate if the xml declaration (ie <?xml ... ?>) should be rendered.
	 * @var bool
	 */
	protected $showXmlDeclaration = false;
	
	/**
	 * No-arg constructor.
	 */
	public function __construct()
	{
		$this->contentType = self::DEFAULT_CONTENT_TYPE;
	}
	
	public function doRendering()
	{
		$this->inputDocument = new \DOMDocument();
		$this->inputDocument->load($this->template);
		
		$outputDocument = $this->createOutputDocument();
		
		$outputDocument->preserveWhiteSpace = false;
		$outputDocument->formatOutput = true;
		
		try
		{
			$this->processNode($this->inputDocument->documentElement, $outputDocument, $this->context);
		}
		catch (xml\CompositionException $e)
		{
			$masterTemplate = $this->inputDocument->documentElement->getAttributeNS(self::TEMPLATE_NAMESPACE, 'composition-of');
			
			$templateProcessor = new XmlRenderer();
			$templateProcessor->template = $masterTemplate;
			$templateProcessor->context = $this->context;
			$templateProcessor->composition = $this->inputDocument;
			
			$this->inputDocument->documentElement->removeAttributeNS(self::TEMPLATE_NAMESPACE, 'composition-of');
			
			return $templateProcessor->doRendering();
		}
		
		$targetNodes = $this->getTargetNodes($outputDocument);
		
		$xmlParts = array();
		foreach ($targetNodes as $node)
		{
			array_push( $xmlParts, $outputDocument->saveXML($node) );
		}
		
		$xml = implode("\n", $xmlParts);
		
		// force self-closing tags to separate into both open and close tags
		// TODO add a boolean property to force separate tags (default to false ?)
		return preg_replace('/<(\w+(:\w+)?)([^<>]*)\/>/', '<$1$2$3></$1$2>', $xml);
	}
	
	private function createOutputDocument()
	{
		$dtd = @$this->inputDocument->implementation->createDocumentType(
			$this->inputDocument->doctype->name,
			$this->inputDocument->doctype->publicId,
			$this->inputDocument->doctype->systemId
		);
		
		if ($dtd === false)
		{
			return new \DOMDocument();
		}
		
		return $this->inputDocument->implementation->createDocument(null, null, $dtd);
	}
	
	private function processNode(\DOMNode $inputDocument, \DOMNode $outputParent, array $context)
	{
		$clone = $this->cloneNode($inputDocument, $outputParent);
		
		$templateAttributes = $this->processAttributes($inputDocument, $clone, $context);
		
		if (isset($templateAttributes['rendered']) && $templateAttributes['rendered'] != 'true')
		{
			return;
		}
		
		if ( isset($templateAttributes['bundle']) )
		{
			$this->processBundle($context, $templateAttributes);
		}
		
		if ( isset($templateAttributes['composition-of']) )
		{
			if ($inputDocument->parentNode != $inputDocument->ownerDocument)
			{
				throw new \switcher\exceptions\RendererException('Composition can only be a root node.');
			}
			
			$this->checkNestedIncludes($this->inputDocument);
			
			throw new xml\CompositionException();
		}
		
		if ( isset($templateAttributes['include']) )
		{
			if ($templateAttributes['include'] == 'composition' && $this->composition != null)
			{
				return $this->processNode($this->composition->documentElement, $outputParent, $context);
			}
			
			if ($templateAttributes['include'] != 'composition')
			{
				try
				{
					$include = @\DOMDocument::load($templateAttributes['include']);
				}
				catch (\Exception $e)
				{
				}
			}
			
			if (isset($include) && $include != null)
			{
				$this->checkNestedIncludes($include);
				return $this->processNode($include->documentElement, $outputParent, $context);
			}
		}
		
		if ( isset($templateAttributes['for']) )
		{
			$this->processFor($inputDocument, $clone, $outputParent, $context, $templateAttributes);
		}
		elseif ( isset($templateAttributes['for-each']) )
		{
			$this->processForEach($inputDocument, $clone, $outputParent, $context, $templateAttributes);
		}
		elseif ( isset($templateAttributes['for-children']) )
		{
			$outputParent->appendChild($clone);
			$this->processForChildren($inputDocument, $clone, $context, $templateAttributes);
		}
		else // no particular template directive
		{
			$outputParent->appendChild($clone);
			
			if ($clone->nodeType == XML_TEXT_NODE)
			{
				$this->processNodeExpressions($clone, $context);
			}
			
			$this->processChildren($inputDocument, $clone, $context);
		}
	}
	
	private function processBundle(array &$context, array &$templateAttributes)
	{
		if ( !isset($templateAttributes['bundle-var']) )
		{
			throw new \switcher\exceptions\RendererException('Missing bundle variable');
		}
		
		$ini = $templateAttributes['bundle'];
		$var = $templateAttributes['bundle-var'];
		
		$this->checkContextVariableName($var);
		
		try
		{
			$context[$var] = \switcher\utils\InternationalizationHelper::loadBundle($ini);
		}
		catch (\Exception $e)
		{
			throw new \switcher\exceptions\RendererException($e->getMessage(), $e->getCode(), $e);
		}
	}
	
	private function processFor(\DOMNode $inputDocument, \DOMNode $clone, \DOMNode $parent, array &$context, array &$templateAttributes)
	{
		if ( !preg_match('/^\s*\d+\s*\.\.\s*\d+\s*$/', $templateAttributes['for']) )
		{
			throw new \switcher\exceptions\RendererException('Invalid range \'' . $templateAttributes['for-each'] . '\'');
		}
		
		$bounds = preg_split('/\.\./', $templateAttributes['for']);
		
		$low = trim( $bounds[0] );
		$high = trim( $bounds[1] );
		
		$array = range($low, $high);
		
		$var = isset($templateAttributes['for-var']) ? $templateAttributes['for-var'] : null;
		$isForChildren = isset($templateAttributes['for-children']);
		
		$this->processForLoop($inputDocument, $clone, $parent, $context, $array, $var, $isForChildren);
	}
	
	private function processForEach(\DOMNode $inputDocument, \DOMNode $clone, \DOMNode $parent, array &$context, array &$templateAttributes)
	{
		if ( !isset($context[ $templateAttributes['for-each'] ]) )
		{
			throw new \switcher\exceptions\RendererException('Unknown context variabe \'' . $templateAttributes['for-each'] . '\'');
		}
		
		$array = $context[ $templateAttributes['for-each'] ];
		
		if ( !is_array($array) )
		{
			$array = array($array);
		}
		
		$var = isset($templateAttributes['for-each-var']) ? $templateAttributes['for-each-var'] : null;
		$isForChildren = isset($templateAttributes['for-children']);
		
		$this->processForLoop($inputDocument, $clone, $parent, $context, $array, $var, $isForChildren);
	}
	
	private function processForLoop(\DOMNode $inputDocument, \DOMNode $clone, \DOMNode $parent, array &$context, array &$array, &$var, &$isForChildren)
	{
		if ($var != null)
		{
			self::checkContextVariableName($var);
		}
		
		foreach ($array as $item)
		{
			$loopClone = $clone->cloneNode(false);
			$parent->appendChild($loopClone);
			
			if ($var != null)
			{
				$context[$var] = $item;
			}
			
			if ($isForChildren)
			{
				$outputParent->appendChild($clone);
				$this->processForChildren($inputDocument, $clone, $context, $templateAttributes);
			}
			else
			{
				$this->processChildren($inputDocument, $loopClone, $context);
			}
		}
		
		if ($var != null)
		{
			unset($context[$var]);
		}
	}
	
	private function processForChildren(\DOMNode $inputDocument, \DOMNode $output, array &$context, array &$templateAttributes)
	{
		if ( !isset($context[ $templateAttributes['for-children'] ]) )
		{
			throw new \switcher\exceptions\RendererException('Unknown context variabe \'' . $templateAttributes['for-children'] . '\'');
		}
		
		$array = $context[ $templateAttributes['for-children'] ];
		
		if ( !is_array($array) )
		{
			$array = array($array);
		}
		
		$var = isset($templateAttributes['for-children-var']) ? $templateAttributes['for-children-var'] : null;
		
		if ($var != null)
		{
			self::checkContextVariableName($var);
		}
		
		foreach ($array as $item)
		{
			if ($var != null)
			{
				$context[$var] = $item;
			}
			
			foreach ($inputDocument->childNodes as $childNode)
			{
				$this->processNode($childNode, $output, $context);
			}
		}
		
		if ($var != null)
		{
			unset($context[$var]);
		}
	}
	
	private function processChildren(\DOMNode $inputDocument, \DOMNode $output, array &$context)
	{
		if ($inputDocument->childNodes == null)
		{
			return;
		}
		
		foreach ($inputDocument->childNodes as $childNode)
		{
			$this->processNode($childNode, $output, $context);
		}
	}
	
	private function checkRendererAttributes(array &$templateAttributes)
	{
		static $checkForbiddenFunction = null;
		
		if ($checkForbiddenFunction == null)
		{
			$forbidden = preg_replace('/[\w-]+/', 'isset($attributes[\'$0\'])', self::$_TEMPLATE_FORBIDDEN);
			$forbidden = str_replace(array('&', '|', '^'), array('and', 'or', 'xor'), $forbidden);
			
			$checkForbiddenFunction = create_function('$attributes', "return ($forbidden);");
		}
		
		if ( $checkForbiddenFunction($templateAttributes) )
		{
			throw new \switcher\exceptions\RendererException('Forbidden template directives.');
		}
	}
	
	private function processAttributes(\DOMNode $inputDocument, \DOMNode $output, array &$context)
	{
		$templateAttributes = array();
		
		if ($inputDocument->attributes == null)
		{
			return $templateAttributes;
		}
		
		foreach ($inputDocument->attributes as $name=>$inputAttribute)
		{
			$clone = clone $inputAttribute;
			@$this->processNodeExpressions($clone, $context);
			
			if ($inputAttribute->namespaceURI == self::TEMPLATE_NAMESPACE)
			{
				$templateAttributes[$name] = trim($clone->nodeValue);
			}
			else
			{
				$output->setAttributeNS($clone->namespaceURI, $clone->nodeName, $clone->nodeValue);
			}
			
			unset($clone);
		}
		
		$this->checkRendererAttributes($templateAttributes);
		
		return $templateAttributes;
	}

	private function processNodeExpressions(\DOMNode $node, array &$context)
	{
		$matches = array();
		$matchesCount = preg_match_all('/([#$])\{([^{}]*)\}/', $node->nodeValue, $matches);
		
		if ($matchesCount == 0)
		{
			return;
		}
		
		for ($match = 0; $match < $matchesCount; $match++)
		{
			$newValue = $this->processExpression($matches[2][$match], $context);
			
			if ($matches[1][$match] == '#')
			{
				$newValue = htmlentities($newValue);
			}
			
			$node->nodeValue = str_replace($matches[0][$match], $newValue, $node->nodeValue);
		}
		
		if ($node->nodeType == XML_TEXT_NODE)
		{
			$fragment = $node->ownerDocument->createDocumentFragment();
			$fragment->appendXML($node->nodeValue);
			
			$node->parentNode->replaceChild($fragment, $node);
		}
	}

	private function processExpression($expression, array &$context)
	{
		$evaluation = preg_replace('/\$(\w+)/', '$context[\'$1\']', $expression);
		$evaluation = preg_replace('/#(\w+)/', '$_SESSION[\'$1\']', $evaluation);
		
		ob_start(
			function($buffer)
			{
				$error = error_get_last();
				
				if ($error != null && $error['type'] == E_ERROR)
				{
					$buffer = preg_split('/' . self::$_EXPRESSION_DELIMITER . '/', $buffer, 2);
					return 'switcher\renderers\XmlRenderer Fatal Error : ' . $error['message'] . ' in template expression {' . $buffer[0] . '}';
				}
				
				return '';
			}
		);
		
		try
		{
			echo $expression . self::$_EXPRESSION_DELIMITER;
			$function = create_function('$context', "return ($evaluation);");
			
			$evaluation = $function($context);
		}
		catch (\Exception $e)
		{
			throw new \switcher\exceptions\RendererException(error_get_last() . " : $expression");
		}
		
		ob_end_clean();
		
		if ( is_bool($evaluation) )
		{
			return $evaluation ? 'true' : 'false';
		}
		
		return $evaluation;
	}
	
	private function cloneNode(\DOMNode $inputDocument, \DOMNode $output)
	{
		if ( get_class($output) != 'DOMDocument' )
		{
			$output = $output->ownerDocument;
		}
			
		if ($inputDocument->nodeType == XML_ELEMENT_NODE)
		{
			return $output->createElementNS($inputDocument->namespaceURI, $inputDocument->nodeName, null);
		}
		
		return $output->importNode($inputDocument, false);
	}
	
	private function checkNestedIncludes(\DOMDocument $document)
	{
		$elements = $document->getElementsByTagName('*');
		
		foreach ($elements as $element)
		{
			if ( $element->hasAttributeNS(self::TEMPLATE_NAMESPACE, 'include') )
			{
				throw new xml\IllegalIncludeException('Only the root template can include.');
			}
		}
	}
	
	private function getTargetNodes(\DOMDocument $document)
	{
		\switcher\utils\HttpHelper::setAjaxVary();
		
		if ( !\switcher\utils\HttpHelper::isAjaxRequest() || !isset($this->ajax) )
		{
			if ($this->showXmlDeclaration === true || $this->showXmlDeclaration == 'true')
			{
				return array($document);
			}
			
			return array($document->doctype, $document->documentElement);
		}
		
		$xpath = new \DOMXPath($document);
		
		if ( isset($this->ajax['namespaces']) )
		{
			foreach ($this->ajax['namespaces'] as $prefix => $uri)
			{
				$xpath->registerNamespace($prefix, $uri);
			}
		}
		
		return $xpath->query( $this->ajax['xpath'] );
	}
}

?>